/**
 * InvientEngine - Experimental physics and graphics library
 * Copyright (C) 2011  Shane Transue
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see http://www.gnu.org/licenses/gpl.html
 *
 * No line in this file should exceed 80 characters in width.
 */

package com.invient.util; 

import java.util.Hashtable;
import java.util.List;
import java.util.Enumeration;
import java.util.ArrayList;

import java.io.FileInputStream;
import java.io.DataInputStream;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.FileNotFoundException;
import java.io.FileWriter;
import java.io.PrintWriter;
import java.io.DataOutputStream;

/**
 * This class implements a simple configuration manager for an application; the
 * configuration manager loads configuration files such as .lst files and .ini
 * files.
 *
 * This configuration manager can be used to load, save, and create custom
 * configurations for any application. This configuration manager was designed
 * to be used as a standalone component that contains no external depenencies
 * that are not included in Java 1.6.<br /><br />
 * 
 * Configuration files generally contain various file extensions such as
 * .lst in Linux, and .ini in Windows. This configuration manager
 * does not specifically identify any file extension. The filename and it's
 * extension should both be included when loading a configuration file into this
 * configuration manager. The type of configuration file that this manager
 * reads follows the following format:<br /><br />
 * 
 * <font face="Courier New">
 * # Configuration File<br />
 * # Comments are indicated using the # character and are ignored<br />
 * # There are two types of lines:<br />
 * # Group Header Lines<br />
 * # Property=Value pair Lines<br />
 * # These two line types will be illustrated below<br /><br />
 * 
 * [Group Header1]<br />
 * myProperty1=myValue1<br /><br />
 * 
 * [Group Header2]<br />
 * myProperty2=myValue2<br /><br />
 * 
 * # End of Configuration File<br />
 * </font><br />
 * 
 * This code was developed and tested using:<br />
 * Java 1.6.0_25<br />
 * Java(TM) SE Runtime Environment (build 1.6.0_25-b06)
 * Java HotSpot(TM) Client VM (build 20.0-b11, mixed mode, sharing)<br /><br />
 * 
 * <b>Example Usage</b><br />
 * <font face="Courier New"><br />
 * Configuration c = new Configuration();<br />
 * c.load("myConfig.ini");<br />
 * System.out.println(c.toString());
 * </font><br /><br />
 * 
 * This will print the configuration file to the console. The values of 
 * properties can now be accessed using the get(groupName, propertyName) method 
 * and the get(propertyName) method for properties that are part of the global 
 * group.
 */
public class Configuration
{
    /**
     * The configuration delimter represents the delimiter that is used to
     * separate Property=Value pairs. This String is most commonly found to be 
     * the "=" character in most configuration files.
     */
    private final String CONFIGURATION_DELIMITER = "=";
    
    /**
     * The comment character represents the start of a new comment on a line
     * of the configuration file. Comments in the configuration file can only
     * be on a single line.
     */
    private final char COMMENT_CHARACTER = '#';
    
    /**
     * The group name open character represents the opening of a new group
     * section in the configuration file. Group names must be contained within
     * the group name open character and the group close character. The most
     * common group name open character is the [ character.
     * For example: [MyGroupName]
     */
    private final char GROUP_NAME_OPEN = '[';
    
    /**
     * The group name close character represents the closing of a new group
     * section in the configuration file. Group names must be contained within
     * the group name open character and the group close character. The most
     * common group name close character is the ] character.
     * For example: [MyGroupName]
     */
    private final char GROUP_NAME_CLOSE = ']';
    
    /**
     * The null value String represents the value of a property when the
     * property does not contain a value or the value could not be retrieved.
     */
    private final String NULL_VALUE = "None";
    
    /**
     * A configuration is generally composed of several groups, each of which
     * contain several Property=Value pairs. If a configuration file does not
     * contain any groups, then all Property=Value pairs are added to the
     * GLOBAL group. As groups are added or parsed from a file they are
     * contained in this hashtable. To retireve a group, simple use the group's
     * name and the getGroup(...) method.
     */
    private Hashtable<String, Group> groups;
    
    /**
     * The group class represents the internal representation of a group section
     * in a configuration file. A group is defined by its name and the
     * collection of Property=Value pairs that it contains. The example below
     * illustrates a single group in a configuration file:
     * <br />
     * [MyGroup]<br />
     * MyProperty in MyGroup = MyValue in MyGroup<br />
     * <br />
     * If a group of the same name is included multiple times in the
     * configuraiton file, only one instance of that group will contain all
     * of the Property=Value pairs found under that groups name in the
     * configuration file.
     */
    private class Group
    {
        /**
         * If a Property=Value pair is found outside of a group then it will be
         * added to the GLOBAL group by default. This identifier simply defines
         * the name of the GLOBAL group.
         */
        public static final String GLOBAL_NAME = "Global";
        
        /**
         * The name of a group represents a collectoin of Property=Value pairs.
         * The group's name must be unique.
         */
        private String name;
        
        /**
         * This hashtable stores the relationship between unqiue properties
         * and their values. This hashtable maps the value of a specific
         * property to the properties name.
         * The propertie's name must be unique.
         */
        private Hashtable<String, String> properties;
    
        /**
         * Creates a new group that contains an empty hashtable of properties.
         * The default name of a group created with the default constructor
         * will be the GLOBAL group name.
         */
        public Group() {
            this.name = GLOBAL_NAME;
            this.properties = new Hashtable<String, String>();
        }
        
        /**
         * Creates a new group that contains an empty hashtable of properties
         * given a unique name.
         *
         * @param name The new name of the group. This name must be unique.
         */
        public Group(String name) {
            this.name = name;
            this.properties = new Hashtable<String, String>();
        }
        
        /**
         * This method adds a new property to a group. The property represents
         * the key of the map and the value represents the value associated
         * with the provided key.
         *
         * @param property The new property. This property cannot be null.
         * @param value The value of the property. This value cannot be null.
         * @throws NullPointerException if the property or the value 
         * provided are null.
         * @return If this method is successful then it will return true,
         * otherwise it will return false.
         */
        public boolean add(String property, String value) {
            try {
                this.properties.put(property, value);
                return true;
            }
            catch(NullPointerException e) {
                System.err.println("[Exception] Null property or value" +
                    " could not be added");
                e.printStackTrace();
                return false;
            }
        }
        
        /**
         * This method indicates whether or not a property is contained by
         * this group.
         *
         * @param property The property to look for in this group.
         * @return This method will return true if the property is found in this
         * group, if the property is not found it will return false.
         */
        public boolean contains(String property) {
            return this.properties.containsKey(property);
        }
        
        /**
         * This method returns the value of the property provided.
         *
         * @param property The property to be searched for. 
         * @return This method returns the value of the property provided.
         * If this property does not exist in this group then the NULL_VALUE
         * value will be returned.
         */
        public String get(String property) {
            String value = this.properties.get(property);
            if ( value == null )
                return NULL_VALUE;
            return value;
        }
        
        /**
         * This method removes a Property=Value pair from this group.
         *
         * @param property The property which will be removed from this group.
         * @throws NullPointerException if the property provided is null.
         * @return If this method is successful then it will return true,
         * otherwise false.
         */
        public boolean remove(String property) {
            try {
                if ( this.properties.remove(property) == null )
                    return false;
                else
                    return true;
            }
            catch(NullPointerException e) {
                System.err.println("[Exception] Null property could not" +
                    " be removed to set value");
                e.printStackTrace();
                return false;
            }
        }
        
        /**
         * This method sets the value of an existing property. If the property
         * does not exist the it will be added.
         *
         * @param property The property that will have its value set or the
         * property that will be added to this group with its associated value.
         * @return If this method is successful then it will return true,
         * otherwise it will return false.
         */
        public boolean set(String property, String value) {
            if ( this.remove(property) == false )
                return false;
            if ( this.add(property, value) == false )
                return false;
            return true;
        }
        
        /**
         * This method returns the number of Property=Value pairs contained
         * in this group.
         *
         * @return Returns the number of Property=Value pairs contained in this
         * group.
         */
        public int size() {
            return this.properties.size();
        }
        
        /**
         * This method returns the name of this group.
         *
         * @return Returns the name of this group.
         */
        public String getName() {
            return this.name;
        }
        
        /**
         * This method returns a list of the properties that are contained
         * in this group. These property names can be used to look up their
         * values which are also contained in this group.
         *
         * @return Returns a List of Strings that represent the properties
         * contained in this group.
         */
        public List<String> getProperties() {
            List<String> keys = new ArrayList<String>();
            for ( Enumeration e = properties.keys(); e.hasMoreElements(); ) {
                keys.add((String)(e.nextElement()));
            }
            return keys;
        }
        
        /**
         * This method returns the String representation of this group. This
         * includes all of the Property=Value pairs contained in this group.
         * 
         * @return Returns the String representation of this group.
         */
        public String toString() {
            StringBuilder result = new StringBuilder();
            List<String> propertyList = this.getProperties();
            
            result.append("[" + this.name + "]");
            result.append("\n");
            
            for ( int i = 0; i < propertyList.size(); i++ ) {
                result.append(propertyList.get(i));
                result.append(CONFIGURATION_DELIMITER);
                result.append(this.properties.get(propertyList.get(i)));
                result.append("\n");
            }
            return result.toString();
        }
    }
    
    /**
     * Constructs an empty Configuration that contains a single GLOBAL group.
     */
    public Configuration() {
        this.groups = new Hashtable<String, Group>();
        this.add(Group.GLOBAL_NAME);
    }
    
    /**
     * This method adds a new group to this configuration. The new group name
     * must be unique. If the group already exists then this method will return
     * false and the group will not be added.
     * 
     * @param groupName The name of the new group. This name must be unique and
     * not already contained in the group. To test if this group name is already
     * contained in this configuration use the contains(String groupName)
     * method.
     * @return If this method is successful it will return true, otherwise
     * it will return false.
     */
    public boolean add(String groupName) {
        Group newGroup = new Group(groupName);
        if ( this.contains(groupName) == true )
            return false;
        else
            this.groups.put(groupName, newGroup);
        return true;
    }
    
    /**
     * This method is used to add a new group that has been parsed out of a
     * configuration file.
     *
     * @param group The new group to add to this configuration.
     * @return If this method is successful then this method will return true,
     * otherwise it will return false.
     */
    private boolean add(Group group) {
        try {
            this.groups.put(group.getName(), group);
            return true;
        }
        catch(NullPointerException e) {
            System.err.println("[Exception] Null group could not" +
                " be added to configuration");
            e.printStackTrace();
            return false;
        }
    }
    
    /**
     * This method adds a new Property=Value pair to a new group. If the group
     * already exists then this method will add the new Property=Value pair to
     * the existing group.
     *
     * @param groupName The name of the new group. This name must be unqiue.
     * @param property The new property to add to the provided group.
     * @param value The value of the provided property.
     * @return If this method is successful then this method will return true,
     * otherwise it will return false.
     */
    public boolean add(String groupName, String property, String value) {
        Group newGroup = null;
        
        if ( this.contains(groupName) == true ) {
            newGroup = this.getGroup(groupName);
            newGroup.add(property, value);
            return true;
        }
        else {
            newGroup = new Group(groupName);
            newGroup.add(property, value);
            this.add(newGroup);
        }
        return false;
    }
    
    /**
     * This method indicates whether or not a group is contained in this
     * configuration.
     *
     * @param groupName The name of the group to search for.
     * @return If the group is contained in this configuration then this method
     * will return true. If the group is not contained in this configuration
     * then this method will return false;
     */
    public boolean contains(String groupName) {
        return this.groups.containsKey(groupName);
    }
    
    /**
     * This method sets the value of the provided property in the provided
     * group.
     *
     * @param groupName The name of the group that contains the provided
     * Property=Value pair.
     * @param property The property that will have its value changed to the
     * newly provided value.
     * @param value The new value of the provided property.
     * @return If this method is successful then it will return true, otherwise
     * it will return false.
     */
    public boolean set(String groupName, String property, String value) {
        if ( this.contains(groupName) == true ) {
            Group newGroup = this.getGroup(groupName);
            newGroup.set(property, value);
            return true;
        }
        else
            return false;
    }
    
    /**
     * This method removes a group from this configuration.
     *
     * @param groupName The name of the group to be removed from this configuration.
     * @throws NullPointerException If the group name provided is null.
     * @return If this method is successful then it will return true, otherwise
     * it will return false.
     */
    public boolean remove(String groupName) {
        try {
            this.groups.remove(groupName);
            return true;
        }
        catch(NullPointerException e) {
            System.err.println("[Exception] Cannot remove a null" +
                " group name from groups");
            e.printStackTrace();
            return false;
        }
    }
    
    /**
     * This method removes the provided property from the provided group.
     *
     * @param groupName The group from which to remove the provided property.
     * @param property The property to remove.
     * @return If this method is successful then this method will return true,
     * otherwise it will return false.
     */
    public boolean remove(String groupName, String property) {
        if ( this.contains(groupName) == true ) {
            Group newGroup = this.getGroup(groupName);
            newGroup.remove(property);
            return true;
        }
        else
            return false;
    }
    
    /**
     * This method loads a configuration from a file. The file extension does
     * not matter however it must have the proper configuration file format.
     * When the configuration file is loaded all of the groups and
     * Property=Value pairs from the file will be stored internally in this
     * configuration. If either exception is thrown the handle to the file
     * will be automatically closed.
     *
     * @param filename The name of the file that contains the configuration.
     * @throws FileNotFoundException If the file is not found.
     * @throws IOException If an IO exception occurs.
     * @return If loading the file is successful then this method will return
     * true, otherwise it will return false.
     */
    public boolean load(String filename) {
        this.clear();
        
        DataInputStream inputStream = null;
        BufferedReader reader = null;
        String currentLine;
        String currentGroup = Group.GLOBAL_NAME;
        
        try {
            inputStream = new DataInputStream(new FileInputStream(filename));
            reader = new BufferedReader(new InputStreamReader(inputStream));
            
            while ( (currentLine = reader.readLine()) != null ) {
                String currentLineTrim = currentLine.trim();
                
                if ( currentLineTrim.length() != 0 ) {
                    if ( currentLineTrim.charAt(0) == GROUP_NAME_OPEN )
                        currentGroup = this.parseGroup(currentLineTrim);
                    else if ( currentLineTrim.charAt(0) == COMMENT_CHARACTER )
                        continue;
                    else
                        this.parsePair(currentGroup, currentLineTrim);
                }
            }
            return true;
        }
        catch(FileNotFoundException e) {
            System.err.println("[Exception] File: " + filename + " not found");
            e.printStackTrace();
            return false;
        }
        catch(IOException e) {
            System.err.println("[Exception] File: " + filename + " not found");
            e.printStackTrace();
            return false;
        }
        finally {
            this.safelyCloseInput(inputStream);
        }
    }
    
    /**
     * This method parses a group header from a configuration file. The process
     * of parsing a group header is defined by identifying the group open 
     * character and the group close character. If these two characters are
     * in the correct locations (The beginning and end of the group name) then
     * the group name will be successfully parsed. This method provides
     * important error information when the group name does not follow the
     * correct syntax.
     * 
     * @param line The current line of the configuration file.
     * @return The new group name that was successfully parsed from the file.
     */
    private String parseGroup(String line) {
        if ( line.charAt(0) != GROUP_NAME_OPEN ) {
            System.err.println("[Warning] Group: " + line + " contains no " + 
                GROUP_NAME_OPEN + ", defaulting to: " + Group.GLOBAL_NAME);
            return Group.GLOBAL_NAME;
        }
        
        if ( line.charAt(line.length() - 1) == GROUP_NAME_CLOSE ) {
            if ( line.length() == 2 ) {
                System.err.println("[Warning] Empty group name in line: " + 
                    line + " defaulting to: " + Group.GLOBAL_NAME);
                this.add(Group.GLOBAL_NAME);
                return Group.GLOBAL_NAME;
            }
                
            String groupName = line.substring(1, line.length() - 1).trim();
            if ( groupName.length() == 0 ) {
                System.err.println("[Error] Invalid group name (Length is 0)," +
                    " defaulting to: " + Group.GLOBAL_NAME);
                this.add(Group.GLOBAL_NAME);
                return Group.GLOBAL_NAME;
            }
            
            this.add(groupName);
            return groupName;
        }
        else {
            System.err.println("[Error] Group: \"" + line + 
                "\" requires closing: " + GROUP_NAME_CLOSE + " character," +
                " defaulting to: " + Group.GLOBAL_NAME);
            this.add(Group.GLOBAL_NAME);
            return Group.GLOBAL_NAME;
        }
    }
    
    /**
     * This method parses Property=Value pairs. The Property=Value line is
     * split by the CONFIGURATION_DELIMITER. This produces an array of tokens
     * that are parsed. This method includes important error messages to
     * indicate exactly where the process of parsing a Property=Value pair
     * fails.
     *
     * @param currentGroup This parameter represents the last group read
     * from the configuration file that the new Property=Value pair will be
     * added to.
     * @param line The line that contains the Property=Value pair.
     */
    private void parsePair(String groupName, String line) {
        String[] array = line.split(CONFIGURATION_DELIMITER);
        
        if ( array.length > 2 ) {
            System.err.println("[Error] \"" + line + "\" contains an" +
                " illegal: " + CONFIGURATION_DELIMITER);
            return;
        }
        else if ( array.length == 1 ) {
            System.err.println("[Warning] \"" + line + "\" does not contain" +
                " a value, defaulting to: " + NULL_VALUE);
            
            String property = array[0];
            String value = NULL_VALUE;
            
            Group newGroup = this.getGroup(groupName);
            newGroup.add(property.trim(), value.trim());
            return;
        }
        else if ( array.length == 0 ) {
            System.err.println("[Error] Property=Value pair with" +
                " no property or value");
            return;
        }
        else {
            String property = array[0];
            String value = array[1];
            
            if ( property == null ) {
                System.err.println("[Error] Property is not" + 
                    " defined in line: \"" + line + "\"");
                return;
            }
                
            if ( value == null ) {
                System.err.println("[Error] Value is not" +
                    " defined in line: \"" + line + "\"");
                return;
            }
                
            Group newGroup = this.getGroup(groupName);
            if ( newGroup == null ) {
                System.err.println("[Error] Cannot add to group " + groupName +
                    " because this group does not exist");
                return;
            }
                
            newGroup.add(property.trim(), value.trim());
        }
    }
    
    /**
     * This method closes the input stream associated with the input file. This
     * method is called if an exception is thrown while reading a configuration
     * file.
     *
     * @param dataInputStream The stream to be closed.
     */
    private void safelyCloseInput(DataInputStream dataInputStream) {
        try {
            dataInputStream.close();
        }
        catch(IOException e) {
            System.err.println("[Exception] Cannot close" +
                " configuration data input stream");
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }
    
    /**
     * This method saves the configuration information held in this
     * configuraiton into the file specified by the provided filename. The
     * output of this method is take from this configurations toString()
     * method.
     *
     * @param filename The name of the file to write this configuration to.
     * @return If the save was successful then this method will return true,
     * otherwise it will return false.
     */
    public boolean save(String filename) {
        FileWriter outFile = null;
        PrintWriter out = null;
        
        try {
            outFile = new FileWriter(filename);
            out = new PrintWriter(outFile);
            
            out.print(this.toString());
            return true;
        }
        catch(IOException e) {
            System.err.println("[Exception] " + e.getMessage());
            e.printStackTrace();
            return false;
        }
        finally {
            out.close();
        }
    }
    
    /**
     * This method clears the internal information held in this Configuration.
     */
    public void clear() {
        this.groups.clear();
        this.groups = new Hashtable<String, Group>();
        this.add(Group.GLOBAL_NAME);
    }
    
    /**
     * This method returns the group associated with the unique name provided.
     *
     * @param group The name of the group to be retrieved.
     * @return The group with the provided name.
     */
    private Group getGroup(String groupName) {
        return this.groups.get(groupName);
    }
    
    /**
     * This method returns the value of the property provided from the group
     * provided.
     *
     * @param groupName The group that contains the Property=Value pair.
     * @param property The name of the property that will have its value
     * returned.
     * @return Returns the value of the provided property that exists in the
     * provided group.
     */
    public String get(String groupName, String property) {
        Group newGroup = this.getGroup(groupName);
        if ( newGroup == null )
            return NULL_VALUE;
        return newGroup.get(property);
    }
    
    /**
     * This method returns the value of a property that belongs to the GLOBAL
     * group. The property name provided must reference a property that is
     * in the global group.
     *
     * @param property The name of the property that exists in the GLOBAL group.
     * @return The value of the provided property.
     */
    public String get(String property) {
        Group newGroup = this.getGroup(Group.GLOBAL_NAME);
        String value = newGroup.get(property);
        if ( value == null )
            return NULL_VALUE;
        return value;
    }
    
    /**
     * This method provides a List of Strings of the groups contained in this
     * configuration. These group names can be used to retrieve their
     * associated Property=Value pairs.
     *
     * @return The list of group names contained in this configuration.
     */
    public List<String> getGroups() {
        List<String> groups = new ArrayList<String>();
        for ( Enumeration e = this.groups.keys(); e.hasMoreElements(); ) {
            groups.add((String)(e.nextElement()));
        }
        return groups;
    }
    
    /**
     * This method returns the String representation of this configuration.
     * The string representation of a configuration is the same as it would
     * appear if it were saved to a configuration file.
     *
     * @return The String representation of a configuration which is a common
     * configuration file.
     */
    public String toString() {
        StringBuilder result = new StringBuilder();
        List<String> groups = this.getGroups();
        try {
            for ( int i = groups.size() - 1; i >= 0; i-- ) {
                Group group = this.groups.get(groups.get(i));
                result.append(group.toString());
                result.append("\n");
            }
        }
        catch(NullPointerException e) {
            System.err.println("[Exception] Null group name");
            e.printStackTrace();
            return "[Error] Configuration toString failed";
        }
        return result.toString();
    }
}